feat: dark mode support for pyRevit WPF forms - #3582
Conversation
Add a centralized Theme.xaml resource dictionary and inject a semantic light/dark palette into every WPF control's resources at construction, selected from Revit's active UITheme. Theme.xaml re-templates the standard WPF controls (Button, TextBox, ComboBox, ListBox/ListView, CheckBox, RadioButton, Expander, GroupBox, TabItem, ScrollBar, ContextMenu, MenuItem, ToolTip, Separator, Label) against pyRevit<*>Brush resources so descendants pick the active palette via DynamicResource. WPFWindow now also recolors the native OS title bar to match: a new pyRevitLabs.Common.DwmApi wrapper toggles DWMWA_USE_IMMERSIVE_DARK_MODE on every supported Windows version and additionally sets the exact caption/text colors on Windows 11 22000+. Both work runs are no-ops on older Windows. Title bar colors track live theme changes via a new ThemeChanged subscription; existing form XAMLs were switched from hardcoded White/Black/DimGray/etc. to those DynamicResource brushes, and the duplicated DatePicker ControlTemplate in GetValueWindow.xaml was dropped in favor of the new themed style.
WPFWindow defaults _live_refresh_root_colors to False so a live ThemeChanged refresh does not clobber the literal transparent Background that HUD-style overlays (CommandSwitchWindow, SearchPrompt, ...) declare in their own XAML. WPFPanel overrides it to True: dockable panes stay open for the whole Revit session and never use the transparent-HUD pattern, so they need the live refresh to actually repaint their Background/Foreground. apply_theme was a thin wrapper that just delegated to setup_resources(set_root_colors=False); collapse it back into _on_theme_refresh so the per-subclass _live_refresh_root_colors attribute is the single source of truth.
|
Unable to trigger custom agent "Code Reviewer". You have run out of credits 😔 |
There was a problem hiding this comment.
Pull request overview
This PR adds automatic light/dark theming to all pyRevit WPF forms. A new central Theme.xaml resource dictionary is merged into every WPFWindow/WPFPanel, and a semantic palette (resolved from Revit's active UITheme) is injected as pyRevit*Color/pyRevit*Brush resources by _WPFMixin.setup_resources. Built-in WPF controls are re-templated against those brushes, native title bars are re-skinned via a new DwmApi P/Invoke wrapper, and windows/panes subscribe to Revit's ThemeChanged event to repaint live. Existing first-party XAML is swept to replace hardcoded light-only colors with the new theme brushes.
Changes:
- New
Theme.xamlrestyles standard WPF controls;_ipy.pyresolves the palette, injects brushes, applies DWM title-bar colors, and wires liveThemeChangedrefresh. - New
pyRevitLabs.Common.DwmApiwrapper for immersive dark mode + caption/text colors. - First-party XAML (SelectFromList, GetValueWindow, Settings, Extensions, DevTools panels) swept from hardcoded colors to theme brushes.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
pyrevitlib/pyrevit/forms/Theme.xaml |
New resource dictionary re-templating standard controls against the semantic palette |
pyrevitlib/pyrevit/forms/_ipy.py |
Palette definition, resource injection, DWM title-bar theming, live ThemeChanged subscribe/refresh |
pyrevitlib/pyrevit/forms/SelectFromList.xaml |
Removed light-only styles; icons bound to theme foreground/brushes |
pyrevitlib/pyrevit/forms/GetValueWindow.xaml |
Dropped hardcoded DatePicker template in favor of theme brushes |
dev/pyRevitLabs/pyRevitLabs.Common/Unmanaged.cs |
New DwmApi wrapper (immersive dark mode + caption/text colors) |
extensions/.../SettingsWindow.xaml |
Swept hardcoded colors to theme brushes; styles BasedOn theme defaults |
extensions/.../ExtensionsWindow.xaml |
Swept hardcoded colors; list/checkbox styles BasedOn theme defaults |
extensions/pyRevitDevTools.extension/SamplePanel.xaml |
Removed Background="White"; styles BasedOn theme defaults |
extensions/pyRevitDevTools.extension/DockableExample.xaml |
Removed hardcoded Background="White" |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Add the editable text box template part to ComboBox so the editor surface picks up the theme brushes, and replace the custom ListViewItem template with style triggers that bind directly to pyRevit theme brushes.
|
ready for review @romangolev ? |
Add dark-mode styles for DatePicker, DatePickerTextBox, and Calendar with matching day/month/year chrome, swap the Expander header arrow for a chevron-in-circle, and adjust the dark palette tones for better contrast on hover, pressed, scrollbar, and button surfaces. Also drop the separator rows between settings expanders and tighten their margins.
The custom AnimatedSwitch style hardcoded its own colors so it never picked up dark mode. Replace it with the themed CheckBox across the settings window so toggles follow the active palette. While here, add disabled-state triggers to TextBox, ComboBox, and the Expander toggle, give the expander chevron its own rotation host, and introduce a proper SelectionBackground color in the palette so selected controls render correctly in light and dark.
Resolve the settings window icon via revit.ui.resolve_icon_file so it picks up pyrevit_settings.dark.png under the dark theme, mirroring the bundle icon convention. Hook the resolution into _on_theme_refresh so live theme switches repaint the icon, and ship the new dark asset under netcore, netfx, and the root bin-assets folder.
Promote the inline comments around SetImmersiveDarkMode and SetTitleBarColors into proper XML doc comments, drop the redundant XAML section banners since the style names are self-describing, and reshape the _WPFMixin class docstring plus setup_resources and _live_refresh_root_colors into Google-style Attributes/Important/Note sections.
it's now good enough for evaluation |
|
btw, this is cool... |
Hardcoded accent backgrounds in user XAML — danger and confirm buttons, branded swatches — stayed legible in light mode but vanished under dark mode because the inherited near-white foreground never contrasted against them. Walk the logical tree once on window construction and on every theme refresh; for every element that authored a background (skipping inherited/default values and theme brushes), pick whichever of pyRevitWindowForegroundBrush or pyRevitWindowBackgroundBrush scores higher per WCAG contrast and assign it to the matching foreground dependency property. Add ControlOverlayHover/Pressed, DangerBackground, and SuccessBackground to both palettes, and refactor Button/ListBoxItem templates to paint hover and pressed through a transparent overlay Border so the contrast pass owns the actual text color. Theme a Slider from scratch with a round thumb and themed track, restore ListViewItem template so non-GridView Views still render, swap icon brushes in SettingsWindow/ExtensionsWindow/SelectFromList to DynamicResource, and replace the example/test pushbutton hex colors with the new danger/success brushes. SettingsWindow now generates per-control attributes rather than implicit Window.Resources styles — implicit styles shadow the merged Theme.xaml styles — wraps the schema in a ScrollViewer with a max_height cap, and ColorSplasher drops its custom button overrides, normalizes control heights, and disables virtualization so _update_listbox_colors can reach every ListBoxItem.
The previous styling pass bumped ColorSplasher ComboBox, Button, and TextBox heights to 24 and the themed Button/ComboBox default Padding to 8,3 / 6,3, which made rows noticeably taller than the surrounding labels and the legend stack. Revert ColorSplasher controls back to 20 and tighten the themed defaults to 8,1 / 6,1 so they sit flush with neighbouring controls.
Theme.xaml: add a horizontal-bar Path alongside the check mark and an IsChecked={x:Null} trigger so an indeterminate CheckBox renders a dash, recolor that path in the disabled-state trigger, and let the caption recognize access keys. _ipy.py: tighten the contrast pass to skip translucent backgrounds (alpha != 0xFF) — their effective color depends on what is underneath — and drop the now-unused _CONTRAST_MIN_ALPHA constant; expand the apply_contrast_foregrounds docstring to record the new rule. settings_window.py: remove the now-redundant inline comments above the layout constants since the names already carry the intent.
Theme.xaml: the themed CheckBox template put the check and indeterminate Paths as siblings directly inside a 14x14 Border. Decorator only accepts one child, so the indeterminate bar never rendered. Wrap both in a Grid so they share the box and the IsChecked={x:Null} trigger can show the bar. Also note next to the ListViewItem template why an explicit one is required — the default Aero template paints its own glossy gradient and ignores Background/Foreground. hooks.py: guard get_event_hooks against a missing handler and return [] instead of crashing on None.GetAllEventHooks().
|
made adjustments, thanks for comments!
Keeping them like this for now. Even Autodesk haven't decide what to do with them for now. They are going to have a parity with same Revit OG forms =) Everything else seems to be fixed, please double check it before merging.
What do you mean by that? Just a big file or is it hard to read? |
Title bars and the settings window action bar are chrome surfaces that should be visually distinct from regular controls. Introduce a dedicated ChromeBackground palette key and route both surfaces to it instead of sharing ControlBackground.
AutoDimWindow painted its own light surfaces -- a #FFF7F7F7 page and five White cards -- while its controls and inherited text color came from the active pyRevit palette. Under a dark theme that put near-white text and dark input fields on white cards. Removing the attributes lets the Window style in Theme.xaml supply the background and lets TextElement.Foreground inherit from the window root, so the dialog tracks either theme with nothing left to keep in sync. The cards lose their raised surface and read as spacing-separated sections.
|
CustomDatePickerStyle declared its own setters but omitted BasedOn, so it replaced the base DatePicker style wholesale and dropped the theme-aware setters the base contributes (including the themed palette added earlier in this branch). Reference the base type so the custom setters layer on top instead of starting from scratch.
RAM-wise, the load that PR produce us slim. Headspace-wise just a big bulky Theme file. Btw, got the issue founded by copilot covered. Should be good to go ^ |














Summary
Theme.xamlresource dictionary injects a semantic light/dark palette (resolved from Revit's activeUITheme) into everyWPFWindow/WPFPanel, and re-templates the standard WPF controls (Button,TextBox,ComboBox,ListBox/ListView,CheckBox,RadioButton,Expander,GroupBox,TabItem,ScrollBar,ContextMenu,MenuItem,ToolTip,Separator,Label) against it, so both first-party dialogs (Settings, Extensions, list pickers, etc.) and third-party extension forms/dockable panels pick up the theme automatically.pyRevitLabs.Common.DwmApiwrapper (DWMWA_USE_IMMERSIVE_DARK_MODEon any supported Windows version, exact caption/text colors on Windows 11 22000+), rather than replacing OS chrome with custom-drawn chrome — keeps drag/resize/snap/min/max/close fully native.ThemeChangedevent and repaint in place when the user toggles Revit's theme, no reload required.forms/,pyRevitCore.extension, andpyRevitDevTools.extensionsample panels was swept for hardcoded light-only colors (White/Black/DimGray/etc.) and switched to the new theme brushes.Test plan